Skip to content

perf: Optimize geolocation with local MaxMind database and caching#361

Merged
superdav42 merged 6 commits intomainfrom
feature/optimized-geolocation
Mar 13, 2026
Merged

perf: Optimize geolocation with local MaxMind database and caching#361
superdav42 merged 6 commits intomainfrom
feature/optimized-geolocation

Conversation

@superdav42
Copy link
Collaborator

@superdav42 superdav42 commented Mar 8, 2026

Summary

This PR dramatically improves geolocation performance by eliminating external HTTP calls that were causing 1+ second delays on every page load.

Problem

The existing geolocation implementation made external HTTP calls to services like ipinfo.io and ip-api.com on every request. SPX profiling showed:

  • WP_Ultimo\Geolocation::geolocate_ip - 1,117ms (2 external API calls)
  • WP_Ultimo\Geolocation::get_external_ip_address - 645ms (external IP lookup)
  • Total geolocation overhead: ~1.1 seconds per uncached request

Solution

1. Use Local MaxMind Database

  • Leverages WooCommerce's bundled MaxMind GeoLite2 database reader
  • Falls back to WP Ultimo's own database path if WooCommerce isn't available
  • No external HTTP calls required

2. Multi-Layer Caching

  • In-memory static cache: Same IP lookups within a request are instant
  • Object cache integration: Cross-request caching with configurable TTL (24 hours)
  • Empty results are also cached to prevent repeated failed lookups

3. Improved IP Detection

Headers are now checked in order of trust:

  1. CF-Connecting-IP (Cloudflare)
  2. True-Client-IP (Cloudflare Enterprise / Akamai)
  3. X-Real-IP (Nginx proxy)
  4. X-Forwarded-For (Standard proxy - first IP only)
  5. REMOTE_ADDR (Direct connection)

4. Performance Optimizations

  • MaxMind Reader instance is reused across lookups
  • Country code from CDN headers (e.g., HTTP_CF_IPCOUNTRY) is used when available
  • Proper IPv4/IPv6 validation with port stripping

Breaking Changes

  • get_external_ip_address() no longer makes external API calls (kept for backwards compatibility)
  • External IP lookup APIs are no longer used (can be re-enabled via wu_geolocation_ip_lookup_apis filter returning non-empty array)

Testing

  1. Ensure WooCommerce MaxMind integration is configured with a license key
  2. Visit the signup page and verify country is detected correctly
  3. Check that no external HTTP calls are made (via Query Monitor or similar)
  4. Verify caching works by checking object cache hits

Performance Impact

Metric Before After
Geolocation time ~1,100ms <1ms (cached) / ~5ms (db lookup)
External HTTP calls 2 per request 0
Memory usage Minimal +~1KB for Reader instance

Summary by CodeRabbit

  • New Features

    • Local MaxMind-based geolocation with per-request and cross-request caching and structured IP/country results.
  • Performance

    • Eliminated external API calls for faster, consistent lookups.
  • Reliability

    • Improved IP detection across common proxy/CDN headers and centralized validation; graceful initialization/shutdown and error logging.
  • Bug Fixes

    • Safer cache clearing and guarded teardown to avoid runtime interruptions.
  • Tests

    • Extensive unit tests covering geolocation, IP parsing, caching, fallback and update behavior.

Performance improvements:
- Use WooCommerce's bundled MaxMind database reader (eliminates external API calls)
- Add in-memory static cache for same-request lookups
- Add object cache integration for cross-request caching
- Improve IP detection for CloudFlare, proxies, and load balancers
- Reuse MaxMind Reader instance across lookups
- Fall back gracefully when database is unavailable

This eliminates the 1+ second delays caused by external HTTP calls
to geolocation APIs (ipinfo.io, ip-api.com) on every page load.

Key changes:
- get_ip_address(): Now checks CF-Connecting-IP, True-Client-IP headers
- geolocate_ip(): Uses multi-layer caching (memory -> object cache -> db)
- geolocate_via_db(): Reuses MaxMind Reader, uses WooCommerce's database
- get_external_ip_address(): Deprecated, no longer makes external calls
- Added clear_cache() method for cache invalidation
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 8, 2026

📝 Walkthrough

Walkthrough

Replaces external geolocation calls with a local MaxMind DB approach: adds Reader lifecycle (init/close_reader), per-request and persistent caching, header-based IP extraction/validation, geolocate_ip flow with DB lookup/update scaffolding, cache controls/constants, and comprehensive unit tests.

Changes

Cohort / File(s) Summary
Geolocation Core
inc/class-geolocation.php
Full rewrite to use MaxMind DB reader: adds Reader lifecycle (init/close_reader), in-request memory cache and object-cache persistence (CACHE_GROUP, CACHE_TTL), header-aware IP extraction and centralized validate_ip/is_private_ip, geolocate_ip workflow (memory → headers → object cache → DB), get_local_database_path, supports_geolite2, update_database/maybe_update_database, clear_cache, deprecated stubs, and safer shutdown/error logging.
Dependency
composer.json
Adds runtime dependency maxmind-db/reader (^1.12) to enable local MaxMind DB access.
Tests
tests/WP_Ultimo/Geolocation_Test.php
Adds comprehensive PHPUnit coverage: IP validation (IPv4/IPv6/ports/edge cases), private IP detection, header country extraction, DB path/support checks, caching behavior (in-memory and persistent), clear_cache, get_external_ip_address compatibility, update_database/maybe_update_database, close_reader handling, and many edge cases; uses reflection to reset static state between tests.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client/Request
    participant Geoloc as Geolocation
    participant Mem as In‑memory Cache
    participant ObjCache as Object Cache
    participant Reader as MaxMind DB Reader

    Client->>Geoloc: geolocate_ip(request_ip)
    activate Geoloc

    Geoloc->>Geoloc: validate_ip / get_country_from_headers
    Geoloc->>Mem: check per-request cache
    alt memory hit
        Mem-->>Geoloc: return cached result
    else memory miss
        Geoloc->>ObjCache: check persistent cache (CACHE_GROUP)
        alt persistent hit
            ObjCache-->>Geoloc: return cached result
        else persistent miss
            Geoloc->>Reader: init() / query local MaxMind DB
            activate Reader
            Reader-->>Geoloc: country/state
            deactivate Reader
            Geoloc->>ObjCache: store result (TTL)
        end
        Geoloc->>Mem: store in-memory result
    end

    deactivate Geoloc
    Geoloc-->>Client: return { ip, country, state }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 I hop through headers, sniff each byte,
I keep a local DB warm at night,
I cache the hops both near and far,
I map each IP by land and star,
A rabbit indexes where you are. ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: replacing external geolocation calls with a local MaxMind database and adding caching layers for performance optimization.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/optimized-geolocation
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

github-actions bot commented Mar 8, 2026

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
inc/class-geolocation.php (1)

399-424: Remove or use these unused private methods.

Static analysis correctly identifies that supports_geolite2() and is_geolocation_enabled() are never called. Since they're private, they cannot be used externally.

Options:

  1. Remove them if not needed
  2. Use them where appropriate (e.g., supports_geolite2() could guard the DB lookup in geolocate_via_db())
  3. If intended for future use, add a @todo annotation explaining the plan
♻️ Option 1: Remove unused methods
-    /**
-     * Check if server supports MaxMind GeoLite2 Reader.
-     *
-     * `@return` bool
-     */
-    private static function supports_geolite2(): bool
-    {
-        // Check if WooCommerce's MaxMind reader is available
-        if (class_exists('MaxMind\Db\Reader')) {
-            return true;
-        }
-
-        // Check if WooCommerce is installed with the reader
-        if (defined('WC_ABSPATH')) {
-            $reader_path = WC_ABSPATH . 'vendor/maxmind-db/reader/src/MaxMind/Db/Reader.php';
-            return file_exists($reader_path);
-        }
-
-        return false;
-    }
-
-    /**
-     * Check if geolocation is enabled.
-     *
-     * `@param` string $current_settings Current geolocation settings.
-     * `@return` bool
-     */
-    private static function is_geolocation_enabled(string $current_settings): bool
-    {
-        return in_array($current_settings, ['geolocation', 'geolocation_ajax'], true);
-    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/class-geolocation.php` around lines 399 - 424, These two private methods,
supports_geolite2() and is_geolocation_enabled(), are never called; either
remove them or wire them into existing logic: if you don't need them, delete
both methods; otherwise call supports_geolite2() from geolocate_via_db() to
guard MaxMind DB lookups instead of duplicating checks, and replace any ad-hoc
geolocation setting checks with is_geolocation_enabled($current_settings) where
the code currently tests for 'geolocation'/'geolocation_ajax'; if you intend to
keep them for future work, add a `@todo` explaining planned usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@inc/class-geolocation.php`:
- Around line 301-313: The code currently requires the MaxMind Reader file
($wc_reader) before loading the Composer autoloader ($autoload), which can cause
missing-dependency errors; change the order so that when WC_ABSPATH vendor files
exist you first check for and require_once the autoload file ($autoload) and
then require_once the Reader.php ($wc_reader) if still needed, preserving the
existing checks around defined('WC_ABSPATH') and file_exists and keeping the
class_exists('MaxMind\\Db\\Reader') guard.
- Around line 28-41: Move the opening brace for the class Geolocation to the
same line as the class declaration, replace space indentation with tabs inside
the class docblocks/members (affecting the docblock and constant declarations
around CACHE_GROUP and CACHE_TTL), and replace the hardcoded TTL value 86400
with the DAY_IN_SECONDS constant (i.e., set CACHE_TTL = DAY_IN_SECONDS) so
formatting and readability conform to the pipeline rules.
- Around line 242-246: The 'state' field is left empty in the $result array
returned by geolocate_via_db(); update geolocate_via_db() to extract the first
MaxMind subdivision ISO code when present (check
isset($data['subdivisions'][0]['iso_code'])), set $state =
strtoupper(sanitize_text_field(...)) and use that value for the 'state' key in
the $result array, falling back to an empty string if not available.

---

Nitpick comments:
In `@inc/class-geolocation.php`:
- Around line 399-424: These two private methods, supports_geolite2() and
is_geolocation_enabled(), are never called; either remove them or wire them into
existing logic: if you don't need them, delete both methods; otherwise call
supports_geolite2() from geolocate_via_db() to guard MaxMind DB lookups instead
of duplicating checks, and replace any ad-hoc geolocation setting checks with
is_geolocation_enabled($current_settings) where the code currently tests for
'geolocation'/'geolocation_ajax'; if you intend to keep them for future work,
add a `@todo` explaining planned usage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4be3c0ca-2a81-4154-904f-f8de03933732

📥 Commits

Reviewing files that changed from the base of the PR and between 18c9c97 and c8568ed.

📒 Files selected for processing (1)
  • inc/class-geolocation.php

Comment on lines +242 to +246
$result = [
'ip' => $ip_address,
'country' => is_string($country_code) ? $country_code : '',
'state' => '',
];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how state data is used across the codebase
rg -n "ip_state|'state'" --type php -C 2 | head -80

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 4540


🏁 Script executed:

# Find class-customer.php and search for ip_state usage
fd -e php | xargs rg -l "ip_state" | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 189


🏁 Script executed:

# Search more broadly for ip_state in the codebase
rg "ip_state" -n --type php | head -40

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 342


🏁 Script executed:

# Find the geolocate_via_db method implementation
rg "geolocate_via_db" -n --type php -A 30 | head -80

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 4243


🏁 Script executed:

# Check for MaxMind API/database handling and subdivision data
rg "subdivisions|iso_code" -n --type php | head -30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 290


🏁 Script executed:

rg "private static function geolocate_via_db" -A 50 --type php inc/class-geolocation.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1955


🏁 Script executed:

# Also check the full MaxMind usage to see what data is available
rg "self::\$reader" -B 3 -A 15 --type php inc/class-geolocation.php | head -80

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


State field is always empty and should be populated from MaxMind subdivision data.

The 'state' field is hardcoded to an empty string, but it's actively used throughout the codebase:

  • Stored in customer metadata (class-customer.php line 558)
  • Displayed in the admin interface (class-customer-edit-admin-page.php line 1264)
  • Used for filtering by state (countries.php line 492)

The geolocate_via_db() method retrieves the full MaxMind data but only extracts the country code. Extract the subdivision/state code by accessing $data['subdivisions'][0]['iso_code'] when available and return it in the result array:

$state = '';
if (isset($data['subdivisions'][0]['iso_code'])) {
    $state = strtoupper(\sanitize_text_field($data['subdivisions'][0]['iso_code']));
}

Then populate the 'state' field in the result array instead of hardcoding an empty string.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/class-geolocation.php` around lines 242 - 246, The 'state' field is left
empty in the $result array returned by geolocate_via_db(); update
geolocate_via_db() to extract the first MaxMind subdivision ISO code when
present (check isset($data['subdivisions'][0]['iso_code'])), set $state =
strtoupper(sanitize_text_field(...)) and use that value for the 'state' key in
the $result array, falling back to an empty string if not available.

…erce dependency

- Add maxmind-db/reader ^1.12 to composer dependencies

- Update Geolocation class to use Composer autoloader instead of WooCommerce

- Remove manual loading of WC_ABSPATH/vendor/maxmind-db/reader

- Simplify get_local_database_path() to only check WP Ultimo path

- Update supports_geolite2() to rely on Composer autoloader

- Add comprehensive test suite (35+ tests) for Geolocation class

BREAKING CHANGE: Geolocation no longer checks WooCommerce for MaxMind database
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
inc/class-geolocation.php (1)

228-232: ⚠️ Potential issue | 🟠 Major

state remains permanently empty; this regresses existing consumers.

geolocate_ip() hardcodes 'state' => '' and geolocate_via_db() only returns country. Callers in inc/models/class-customer.php and checkout paths expect state to be meaningful, so this now stores/displays empty state data.

Also applies to: 274-291

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/class-geolocation.php` around lines 228 - 232, geolocate_ip() currently
hardcodes 'state' => '' (and geolocate_via_db() only returns country), which
breaks callers expecting a meaningful state; update both functions so they
extract and return a state/region value from the geolocation provider response
(e.g., use the region/region_code/ subdivision field from the MaxMind/DB record
or provider response), fall back to an empty string only if no state/region is
available, and ensure the returned array keys remain 'ip','country','state' so
callers in class-customer and checkout continue to work; update
geolocate_via_db() to include the same 'state' extraction logic (see
geolocate_ip(), geolocate_via_db()) and any local variables like $record,
$country_code to locate where to read the region.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@inc/class-geolocation.php`:
- Around line 105-107: The early-return guard in the method using
self::$cached_ip is inverted: it currently returns when self::$cached_ip is
null, causing a null to be returned where a string is expected; change the check
to return the cached value only when self::$cached_ip is NOT null (e.g., if
(null !== self::$cached_ip) return self::$cached_ip) so that the first call
computes and sets the cached string and subsequent calls return that string;
update the method that references self::$cached_ip to preserve the declared
string return type.
- Around line 111-129: The current header-parsing in class-geolocation.php
trusts HTTP_CF_CONNECTING_IP / HTTP_TRUE_CLIENT_IP / HTTP_X_REAL_IP /
HTTP_X_FORWARDED_FOR unconditionally; change it to only accept those forwarded
headers when the immediate source
(sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) or similar) is a
known/trusted proxy IP. Implement a trusted-proxy check (e.g., a whitelist array
or a helper like is_trusted_proxy()) before reading HTTP_CF_CONNECTING_IP,
HTTP_TRUE_CLIENT_IP, HTTP_X_REAL_IP or parsing HTTP_X_FORWARDED_FOR, and if the
source is not trusted fall back to REMOTE_ADDR; continue to validate the chosen
candidate with self::validate_ip().

In `@tests/WP_Ultimo/Geolocation_Test.php`:
- Line 210: Replace the deprecated PHPUnit assertion call: change the use of
assertStringContains to assertStringContainsString in the Geolocation_Test class
(the test method containing the assertion referencing $path) so it matches
PHPUnit 9's API; update the assertion invocation where
"$this->assertStringContains('uploads', $path)" appears to call
"$this->assertStringContainsString('uploads', $path)" instead.

---

Duplicate comments:
In `@inc/class-geolocation.php`:
- Around line 228-232: geolocate_ip() currently hardcodes 'state' => '' (and
geolocate_via_db() only returns country), which breaks callers expecting a
meaningful state; update both functions so they extract and return a
state/region value from the geolocation provider response (e.g., use the
region/region_code/ subdivision field from the MaxMind/DB record or provider
response), fall back to an empty string only if no state/region is available,
and ensure the returned array keys remain 'ip','country','state' so callers in
class-customer and checkout continue to work; update geolocate_via_db() to
include the same 'state' extraction logic (see geolocate_ip(),
geolocate_via_db()) and any local variables like $record, $country_code to
locate where to read the region.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 49bb8fa2-5ccf-4db4-b784-64aece73e4c3

📥 Commits

Reviewing files that changed from the base of the PR and between c8568ed and 529f95e.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • composer.json
  • inc/class-geolocation.php
  • tests/WP_Ultimo/Geolocation_Test.php

Comment on lines +111 to +129
// Cloudflare (most trusted when using CF)
if (! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
} elseif (! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) {
// Cloudflare Enterprise / Akamai
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP']));
} elseif (! empty($_SERVER['HTTP_X_REAL_IP'])) {
// Nginx proxy
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP']));
} elseif (! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// Standard proxy header (take first IP - the client)
$forwarded = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
// X-Forwarded-For: client, proxy1, proxy2
$ips = array_map('trim', explode(',', $forwarded));
$ip = self::validate_ip($ips[0]);
} elseif (! empty($_SERVER['REMOTE_ADDR'])) {
// Direct connection
$ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR']));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t trust forwarded headers without a trusted-proxy gate.

This block accepts HTTP_CF_CONNECTING_IP / HTTP_X_FORWARDED_FOR unconditionally. A direct client can spoof these headers and bypass geo-based restrictions.

🔐 Safer direction
-		if (! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
+		$remote_addr = ! empty($_SERVER['REMOTE_ADDR']) ? \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
+		$trust_proxy_headers = (bool) \apply_filters('wu_geolocation_trust_proxy_headers', false, $remote_addr);
+
+		if ($trust_proxy_headers && ! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
 			$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
-		} elseif (! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) {
+		} elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) {
 			$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP']));
-		} elseif (! empty($_SERVER['HTTP_X_REAL_IP'])) {
+		} elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_X_REAL_IP'])) {
 			$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP']));
-		} elseif (! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
+		} elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
 			...
 		} elseif (! empty($_SERVER['REMOTE_ADDR'])) {
 			$ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR']));
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Cloudflare (most trusted when using CF)
if (! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
} elseif (! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) {
// Cloudflare Enterprise / Akamai
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP']));
} elseif (! empty($_SERVER['HTTP_X_REAL_IP'])) {
// Nginx proxy
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP']));
} elseif (! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// Standard proxy header (take first IP - the client)
$forwarded = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
// X-Forwarded-For: client, proxy1, proxy2
$ips = array_map('trim', explode(',', $forwarded));
$ip = self::validate_ip($ips[0]);
} elseif (! empty($_SERVER['REMOTE_ADDR'])) {
// Direct connection
$ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR']));
}
$remote_addr = ! empty($_SERVER['REMOTE_ADDR']) ? \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
$trust_proxy_headers = (bool) \apply_filters('wu_geolocation_trust_proxy_headers', false, $remote_addr);
// Cloudflare (most trusted when using CF)
if ($trust_proxy_headers && ! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
} elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) {
// Cloudflare Enterprise / Akamai
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP']));
} elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_X_REAL_IP'])) {
// Nginx proxy
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP']));
} elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// Standard proxy header (take first IP - the client)
$forwarded = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
// X-Forwarded-For: client, proxy1, proxy2
$ips = array_map('trim', explode(',', $forwarded));
$ip = self::validate_ip($ips[0]);
} elseif (! empty($_SERVER['REMOTE_ADDR'])) {
// Direct connection
$ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR']));
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/class-geolocation.php` around lines 111 - 129, The current header-parsing
in class-geolocation.php trusts HTTP_CF_CONNECTING_IP / HTTP_TRUE_CLIENT_IP /
HTTP_X_REAL_IP / HTTP_X_FORWARDED_FOR unconditionally; change it to only accept
those forwarded headers when the immediate source
(sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) or similar) is a
known/trusted proxy IP. Implement a trusted-proxy check (e.g., a whitelist array
or a helper like is_trusted_proxy()) before reading HTTP_CF_CONNECTING_IP,
HTTP_TRUE_CLIENT_IP, HTTP_X_REAL_IP or parsing HTTP_X_FORWARDED_FOR, and if the
source is not trusted fall back to REMOTE_ADDR; continue to validate the chosen
candidate with self::validate_ip().

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
inc/class-geolocation.php (1)

343-356: Remove or expose unused private methods.

Static analysis flags supports_geolite2() and is_geolocation_enabled() as unused. If these are intended for future use or plugin extensibility, consider making them public. Otherwise, remove them to reduce dead code.

♻️ Option 1: Remove if not needed
-	/**
-	 * Check if server supports MaxMind GeoLite2 Reader.
-	 *
-	 * `@return` bool
-	 */
-	private static function supports_geolite2(): bool {
-		// Check if MaxMind reader is available via Composer autoloader
-		return class_exists('MaxMind\Db\Reader');
-	}
-
-	/**
-	 * Check if geolocation is enabled.
-	 *
-	 * `@param` string $current_settings Current geolocation settings.
-	 * `@return` bool
-	 */
-	private static function is_geolocation_enabled(string $current_settings): bool {
-		return in_array($current_settings, ['geolocation', 'geolocation_ajax'], true);
-	}
♻️ Option 2: Make public for extensibility
-	private static function supports_geolite2(): bool {
+	public static function supports_geolite2(): bool {
...
-	private static function is_geolocation_enabled(string $current_settings): bool {
+	public static function is_geolocation_enabled(string $current_settings): bool {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/class-geolocation.php` around lines 343 - 356, The two private static
methods supports_geolite2() and is_geolocation_enabled() are flagged as unused;
either remove both methods to eliminate dead code, or change their visibility to
public static (and update their docblocks) so they are intentionally exposed for
external use/extension; if choosing to expose, ensure no internal call sites
rely on them remaining private and add a unit test or inline comment stating
they are part of the public API for clarity.
tests/WP_Ultimo/Geolocation_Test.php (2)

432-442: Consider verifying cache hit via reflection.

The test confirms the results are equal, but doesn't verify that the second call actually came from the in-memory cache. You could inspect $memory_cache via reflection to confirm it contains the IP after the first call.

💡 Enhanced test suggestion
public function test_geolocate_ip_caching() {
    $ip = '127.0.0.1';

    // First call
    $result1 = Geolocation::geolocate_ip($ip);

    // Verify cache was populated
    $reflection = new \ReflectionClass(Geolocation::class);
    $memory_cache = $reflection->getProperty('memory_cache');
    if (PHP_VERSION_ID < 80100) {
        $memory_cache->setAccessible(true);
    }
    $cache_value = $memory_cache->getValue();
    $this->assertArrayHasKey($ip, $cache_value);

    // Second call should return from cache
    $result2 = Geolocation::geolocate_ip($ip);

    $this->assertEquals($result1, $result2);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Geolocation_Test.php` around lines 432 - 442, Update
test_geolocate_ip_caching to assert that the in-memory cache was populated after
the first call: after calling Geolocation::geolocate_ip($ip) use ReflectionClass
on Geolocation to get the memory_cache property (handle accessibility for older
PHP versions by calling setAccessible(true) when needed), read the property
value and assert the IP key exists in that array/structure, then proceed to call
Geolocation::geolocate_ip($ip) again and assert results equal; reference
Geolocation::geolocate_ip and the memory_cache property to locate where to add
the reflection check.

234-245: Consider adding a negative test for supports_geolite2() when the class doesn't exist.

The current test only verifies the happy path (Composer package installed). For completeness, you could mock or test the scenario where MaxMind\Db\Reader is not available, though this is difficult without unloading the class.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/WP_Ultimo/Geolocation_Test.php` around lines 234 - 245, Add a negative
test method (e.g., test_supports_geolite2_returns_false_when_class_missing) that
verifies Geolocation::supports_geolite2() returns false when MaxMind\Db\Reader
is unavailable: run it in isolation (use `@runInSeparateProcess` or spawn a
separate PHP process) so the MaxMind class cannot be autoloaded, use
ReflectionClass(Geolocation::class) to get the supports_geolite2 method (as in
the existing test) and invoke it (setAccessible when PHP_VERSION_ID < 80100) and
assertFalse on the result; if your environment supports extensions, you can also
use uopz or runkit to undefine the MaxMind class before calling
supports_geolite2 to simulate the missing-class scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@inc/class-geolocation.php`:
- Around line 69-72: Call Geolocation::init() early in the request lifecycle so
the shutdown handler that invokes Geolocation::close_reader() is registered and
the lazily-created MaxMind Reader used by Geolocation::geolocate_ip() and
Geolocation::get_ip_address() is closed at request end; add a single bootstrap
hook (for example on wp_loaded or in the plugin main bootstrap) that calls
Geolocation::init() during initialization.

---

Nitpick comments:
In `@inc/class-geolocation.php`:
- Around line 343-356: The two private static methods supports_geolite2() and
is_geolocation_enabled() are flagged as unused; either remove both methods to
eliminate dead code, or change their visibility to public static (and update
their docblocks) so they are intentionally exposed for external use/extension;
if choosing to expose, ensure no internal call sites rely on them remaining
private and add a unit test or inline comment stating they are part of the
public API for clarity.

In `@tests/WP_Ultimo/Geolocation_Test.php`:
- Around line 432-442: Update test_geolocate_ip_caching to assert that the
in-memory cache was populated after the first call: after calling
Geolocation::geolocate_ip($ip) use ReflectionClass on Geolocation to get the
memory_cache property (handle accessibility for older PHP versions by calling
setAccessible(true) when needed), read the property value and assert the IP key
exists in that array/structure, then proceed to call
Geolocation::geolocate_ip($ip) again and assert results equal; reference
Geolocation::geolocate_ip and the memory_cache property to locate where to add
the reflection check.
- Around line 234-245: Add a negative test method (e.g.,
test_supports_geolite2_returns_false_when_class_missing) that verifies
Geolocation::supports_geolite2() returns false when MaxMind\Db\Reader is
unavailable: run it in isolation (use `@runInSeparateProcess` or spawn a separate
PHP process) so the MaxMind class cannot be autoloaded, use
ReflectionClass(Geolocation::class) to get the supports_geolite2 method (as in
the existing test) and invoke it (setAccessible when PHP_VERSION_ID < 80100) and
assertFalse on the result; if your environment supports extensions, you can also
use uopz or runkit to undefine the MaxMind class before calling
supports_geolite2 to simulate the missing-class scenario.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 158fcdfa-f4eb-4eff-bb55-750656070488

📥 Commits

Reviewing files that changed from the base of the PR and between c232428 and 015ecd6.

📒 Files selected for processing (2)
  • inc/class-geolocation.php
  • tests/WP_Ultimo/Geolocation_Test.php

Comment on lines +69 to 72
public static function init(): void {
// Register shutdown handler to close MaxMind reader
\register_shutdown_function([self::class, 'close_reader']);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for any calls to Geolocation::init() in the codebase
rg -n "Geolocation::init\(\)" --type php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

#!/bin/bash
# Get the full class-geolocation.php file to understand the design
wc -l inc/class-geolocation.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 110


🏁 Script executed:

#!/bin/bash
# Read the class definition and constructor
cat -n inc/class-geolocation.php | head -100

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3136


🏁 Script executed:

#!/bin/bash
# Search for any wp_hook registrations or filters that might initialize the class
rg -n "add_action|add_filter|do_action|do_filter" inc/class-geolocation.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

#!/bin/bash
# Check how Geolocation class is used in the codebase
rg -n "Geolocation::" --type php -A 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 9379


🏁 Script executed:

#!/bin/bash
# Look for constructor and get_ip_address method
cat -n inc/class-geolocation.php | sed -n '100,180p'

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3114


🏁 Script executed:

#!/bin/bash
# Search for the get_ip_address method and how reader is initialized
rg -n "get_ip_address|function get_reader|reader =" inc/class-geolocation.php -A 3

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 985


🏁 Script executed:

#!/bin/bash
# Check if there's any bootstrap or plugin initialization file that might call init()
rg -n "Geolocation::init" . --type php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

#!/bin/bash
# Get the geolocate_ip method to see how reader is initialized
cat -n inc/class-geolocation.php | sed -n '178,300p'

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 4794


🏁 Script executed:

#!/bin/bash
# Check if there's any automatic PHP cleanup or if close_reader is called elsewhere
rg -n "close_reader|__destruct" inc/class-geolocation.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 195


🏁 Script executed:

#!/bin/bash
# Check if there are any bootstrap/loader files that might initialize
find inc -name "*loader*" -o -name "*bootstrap*" | head -5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 182


🏁 Script executed:

#!/bin/bash
# Check the main autoloader
cat -n inc/class-autoloader.php | head -50

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1219


🏁 Script executed:

#!/bin/bash
# Check the loaders directory
ls -la inc/loaders/

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 266


🏁 Script executed:

#!/bin/bash
# Check for any wp_loaded or init hooks that might call Geolocation
rg -n "add_action.*wp_loaded|add_action.*init" inc/ --type php | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2014


🏁 Script executed:

#!/bin/bash
# Check the development/bootstrap file
cat -n inc/development/bootstrap.php | head -80

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1586


init() must be called to register the shutdown handler — MaxMind reader won't close without it.

The init() method registers close_reader() as a shutdown function (line 71), but Geolocation::init() is never invoked anywhere in the codebase. Without this, the MaxMind Reader instance will not be properly closed when the request ends, causing the file handle to remain open and leak resources.

All production usage (checkout, customer model, settings, etc.) directly calls geolocate_ip() or get_ip_address() without initialization. The reader is lazily instantiated on-demand (line 285), but without the shutdown handler registered, cleanup never occurs.

Add a call to Geolocation::init() in an early hook like wp_loaded or in the main plugin bootstrap to ensure the shutdown handler is registered.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/class-geolocation.php` around lines 69 - 72, Call Geolocation::init()
early in the request lifecycle so the shutdown handler that invokes
Geolocation::close_reader() is registered and the lazily-created MaxMind Reader
used by Geolocation::geolocate_ip() and Geolocation::get_ip_address() is closed
at request end; add a single bootstrap hook (for example on wp_loaded or in the
plugin main bootstrap) that calls Geolocation::init() during initialization.

@superdav42 superdav42 merged commit 70af026 into main Mar 13, 2026
9 checks passed
@superdav42 superdav42 deleted the feature/optimized-geolocation branch March 13, 2026 02:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant